const OPCODE_MAPPINGS = {
    forward: "env.sprite.forward",
    turn: "env.sprite.turn",
    turnLeft: "env.sprite.turnLeft",
    setHeading: "env.sprite.setHeading",
    doFaceTowards: "env.sprite.faceTowards",
    gotoXY: "env.sprite.gotoXY",
    doGotoObject: "env.sprite.goto",
    doGlide: "yield* env.sprite.glide",
    changeXPosition: "env.sprite.changeX",
    setXPosition: "env.sprite.setX",
    changeYPosition: "env.sprite.changeY",
    setYPosition: "env.sprite.setY",
    bounceOffEdge: "env.sprite.bounceOffEdge",

    doSwitchToCostume: "env.setCostume",
    doWearNextCostume: "env.nextCostume",
    doSayFor: "yield* env.sprite.sayFor",
    bubble: "env.sprite.bubble",
    doThinkFor: "yield* env.sprite.thinkFor",
    doThink: "env.sprite.think",
    changeEffect: "env.changeEffect",
    setEffect: "env.setEffect",
    clearEffects: "env.clearEffects",
    getEffect: "env.getEffect",
    changeScale: "env.sprite.changeScale",
    setScale: "env.sprite.setScale",
    getScale: "env.sprite.getScale",
    show: "env.show",
    hide: "env.hide",
    reportShown: "env.isShown",
    goToLayer: "env.sprite.setLayer",
    goBack: "env.sprite.goBack",

    playSound: "env.sprite.playSound",
    doPlaySoundUntilDone: "yield* env.sprite.playSoundAndWait",
    doStopAllSounds: "env.sprite.stopAllSounds",

    clear: "env.penClear",
    down: "env.sprite.penDown",
    up: "env.sprite.penUp",
    getPenDown: "env.sprite.isPenDown",
    setColor: "env.sprite.setPenColor",
    changeSize: "env.sprite.changePenSize",
    setSize: "env.sprite.setPenSize",
    doStamp: "env.sprite.stamp",
    floodFill: "env.sprite.floodFill",
    write: "env.sprite.write",

    doAsk: "env.ask",
    getLastAnswer: "env.lastAnswer",
    reportMouseX: "env.mouseX",
    reportMouseY: "env.mouseY",
    reportMouseDown: "env.mouseDown",
    reportKeyPressed: "env.keyPressed",
    doResetTimer: "env.resetTimer",
    getTimer: "env.timer",

    doBroadcast: "env.broadcast",
    doBroadcastAndWait: "yield* env.broadcastWait",
    doWait: "yield* env.wait",

    reportRandom: "env.random",
};

const YIELD_CODE = "yield* env.wait();";

const EVENT_OPCODES = [
    "receiveGo", "receiveKey", "receiveInteraction", "receiveMessage"
];

var shared;

function compileParam(scope, node, i) {
    return compileItem(scope, node.children[i]);
}

function concatList(scope, listNode, separator, f = compileItem) {
    var res = "";
    var items = listNode.children;

    for (let i = 0; i < items.length; i++) {
        let v = items[i];

        res += f(scope, v);
        if (i < items.length - 1) res += separator;
    }
    
    return res;
}

class Scope {
    constructor(parent) {
        this.parent = parent;
        this.usedNames = new Set();
        this.vars = new Map();

        this.prefix = "_";
        
        // for implicit arguments
        this.params = [];
        this.paramIndex = 0;
    }

    addVariable(varName) {
        let safeBase = this.prefix + varName.replaceAll(/[^A-Za-z0-9_]/ig, "_");
        
        let num = 0;
        let safe = safeBase;
        
        while (this.hasSafeName(safe)) {
            safe = safeBase + `$${num}`;
            num++;
        }
        
        this.usedNames.add(safe);
        this.vars.set(varName, safe);
        
        return safe;
    }

    addParam(varName) {
        var res = this.addVariable(varName)
        this.params.push(res);
        return res;
    }

    getImplictArgument() {
        if (this.paramIndex >= this.params.length) return "";
        return this.params[this.paramIndex++];
    }

    hasSafeName(safeName) {
        if (this.usedNames.has(safeName)) {
            return true;
        } else {
            return this.parent ? this.parent.hasSafeName(safeName) : false;
        }
    }

    get(varName, recursive = true) {
        var res = this.vars.get(varName);

        if (!res && this.parent && recursive) {
            return this.parent.get(varName, recursive);
        }

        return res;
    }
}

function compileUpvar(scope, itemNode) {
    return scope.addVariable(itemNode.textContent);
}

function compileInputName(scope, itemNode) {
    return scope.addParam(itemNode.textContent);
}

function compileItem(scope, itemNode) {
    switch (itemNode.nodeName) {
        // text/number
        case "l": {
            // if child is an option node, return value of option node
            var optionNode = itemNode.children[0];
            if (optionNode && optionNode.nodeName === "option") {
                return `"${optionNode.textContent}"`;
            }

            // TODO string escape characters
            let res = itemNode.textContent;
            
            if (res === "") {
                return scope.getImplictArgument();
            } else {
                return Number.isNaN(+res) ? `"${res}"` : `${res}`;
            }
        }

        case "script": {
            var out = "";

            for (let node of itemNode.children) {
                out += compileItem(scope, node) + ";\n";
            }

            return out;
        }

        case "autolambda": {
            return compileItem(scope, itemNode.firstElementChild);
        }

        case "block": {
            // if this is a variable get block
            if (itemNode.attributes.var) {
                let varName = itemNode.attributes.var.nodeValue;
                let safeName = scope.get(varName);

                if (!safeName) {
                    throw new ReferenceError(`Hmm... Use of undeclared variable "${varName}"`);
                }

                return safeName;
            }
            
            let opcode = itemNode.attributes.s.nodeValue;

            switch (opcode) {
                case "reportLessThan":
                    return `(${compileParam(scope, itemNode, 0)} < ${compileParam(scope, itemNode, 1)})`;

                case "reportGreaterThan":
                    return `(${compileParam(scope, itemNode, 0)} > ${compileParam(scope, itemNode, 1)})`;
                
                case "reportEquals":
                    return `(${compileParam(scope, itemNode, 0)}===${compileParam(scope, itemNode, 1)})`;

                case "reportAnd":
                    return `(${compileParam(scope, itemNode, 0)} && ${compileParam(scope, itemNode, 1)})`;

                case "reportOr":
                    return `(${compileParam(scope, itemNode, 0)} || ${compileParam(scope, itemNode, 1)})`;

                case "reportNot":
                    return `!${compileParam(scope, itemNode, 0)}`;

                case "reportVariadicSum": {
                    // if parameter is a <list>, emit (a + b + c + ..)
                    // instead of env.sum(param)
                    var param = itemNode.firstElementChild;

                    if (param.nodeName === "list") {
                        return `env.sum(${concatList(scope, param, ", ")})`;
                    } else {
                        return `env.sum(...${compileParam(scope, itemNode, 0)})`;
                    }
                }

                case "reportVariadicProduct": {
                    var param = itemNode.firstElementChild;

                    if (param.nodeName === "list") {
                        return `env.product(${concatList(scope, param, ", ")})`;
                    } else {
                        return `env.product(...${compileParam(scope, itemNode, 0)})`
                    }
                }

                case "reportDifference": {
                    return `env.subtract(${compileParam(scope, itemNode, 0)}, ${compileParam(scope, itemNode, 1)})`;
                }

                case "reportQuotient": {
                    return `env.divide(${compileParam(scope, itemNode, 0)}, ${compileParam(scope, itemNode, 1)})`;
                }

                case "reportModulus": {
                    return `(${compileParam(scope, itemNode, 0)} % ${compileParam(scope, itemNode, 1)})`;
                }

                case "reportPower": {
                    return `(${compileParam(scope, itemNode, 0)} ^ ${compileParam(scope, itemNode, 1)})`;
                }

                case "doWaitUntil": {
                    return `do{yield;}while (!${compileParam(scope, itemNode, 0)})`;
                }

                case "doForever": {
                    return `while(true){\n${compileParam(scope, itemNode, 0)}${YIELD_CODE}}`;
                }

                case "doRepeat": {
                    return `for(let i = 0; i < +${compileParam(scope, itemNode, 0)}; i++){\n${compileParam(scope, itemNode, 1)}${YIELD_CODE}}`;
                }

                case "doUntil": {
                    return `while(!${compileParam(scope, itemNode, 0)}){\n${compileParam(scope, itemNode, 1)}${YIELD_CODE}}`;
                }

                case "doIf": {
                    return `if(${compileParam(scope, itemNode, 0)}){\n${compileParam(scope, itemNode, 1)}}`;
                }

                case "doIfElse": {
                    return `if(${compileParam(scope, itemNode, 0)}){\n${compileParam(scope, itemNode, 1)}}else{\n${compileParam(scope, itemNode, 2)}}`;
                }

                case "reportIfElse": {
                    return `${compileParam(scope, itemNode, 0)} ? ${compileParam(scope, itemNode, 1)} : ${compileParam(scope, itemNode, 2)}`;
                }

                case "doFor": {
                    // TODO: for goes backwards when end < start
                    let upvarName = itemNode.children[0].textContent;
                    let start = compileParam(scope, itemNode, 1);
                    let end = compileParam(scope, itemNode, 2);
                    
                    let res = "for (";
                    let safeName = scope.get(upvarName, false);
                    
                    // define variable if not already defined
                    if (safeName) {
                        res += safeName;
                    } else {
                        safeName = scope.addVariable(upvarName);
                        res += `var ${safeName}`;
                    }
                    
                    res += ` = ${start}; ${safeName} <= ${end}; ${safeName}++) {`;
                    let loop = compileParam(scope, itemNode, 3);
                    res += `\n${loop}${YIELD_CODE}}`;

                    return res;
                }

                case "reifyScript":
                case "reifyReporter": {
                    var newScope = new Scope(scope);

                    let paramList = itemNode.children[1];
                    var params = concatList(newScope, paramList, ", ", compileInputName);

                    if (itemNode.children[0].nodeName === "autolambda") {
                        return `function*(${params}){${YIELD_CODE}return ${compileParam(newScope, itemNode, 0)};}`;
                    } else {
                        return `function*(${params}){\n${YIELD_CODE}${compileParam(newScope, itemNode, 0)};}`;
                    }
                }

                case "doRun":
                case "evaluate": {
                    // if arg is a <list>, emit (...)(a, b, c...)
                    // instead of (...).apply(this, args)
                    let target = itemNode.children[0];
                    let args = itemNode.children[1];
                    let post;

                    if (args.nodeName === "list") {
                        post = `(${concatList(scope, args, ", ")})`;
                    } else {
                        post = `.apply(this, ${compileItem(scope, args)})`;
                    }

                    return `yield* (${compileItem(scope, target)})${post}`
                }

                case "fork": {
                    // if arg is a <list>, emit env.fork(f, ...)
                    // instead of env.fork(f, ...(args))
                    let target = itemNode.children[0];
                    let args = itemNode.children[1];
                    let post;

                    if (args.nodeName === "list") {
                        post = `${concatList(scope, args, ", ")}`;
                    } else {
                        post = `...(${compileItem(scope, args)})`;
                    }

                    return `env.fork(${compileItem(scope, target)}, ${post})`
                }

                case "doDeclareVariables": {
                    var listNode = itemNode.children[0];
                    
                    var res = "var ";
                    var items = [...listNode.children];

                    // remove items that are already defined
                    items = items.filter(v => !scope.get(v.textContent, false));

                    if (items.length === 0) return "";

                    // emit variable declarations
                    for (let i = 0; i < items.length; i++) {
                        let v = items[i];

                        res += compileUpvar(scope, v);
                        if (i < items.length - 1) res += ", ";
                    }
                    
                    return res;
                }

                case "doSetVar": {
                    var varName = itemNode.children[0].textContent;
                    var safeName = scope.get(varName);
                    
                    if (!safeName)
                        throw new ReferenceError(`Hmm... Variable "${varName}" does not exist in this context`);
                    
                    return `${safeName} = ${compileParam(scope, itemNode, 1)}`;
                }

                case "doReport": {
                    return `return ${compileParam(scope, itemNode, 0)}`;
                }

                case "doWarp": {
                    return `env.warp(function*(){\n${compileParam(scope, itemNode, 0)};\n})`
                }

                case "reportListAttribute": {
                    var option = compileParam(scope, itemNode, 0);
                    var list = compileParam(scope, itemNode, 1);

                    if (option === "\"length\"") {
                        return `${list}.length`;
                    } else {
                        return `env.listAttrib(${option}, ${list})`;
                    }
                }

                case "doReplaceInList": {
                    var index = compileParam(scope, itemNode, 0);
                    var list = compileParam(scope, itemNode, 1);
                    var value = compileParam(scope, itemNode, 2);

                    return `${list}[${index}-1] = ${value}`;
                }

                case "doAddToList": {
                    return `${compileParam(scope, itemNode, 1)}.push(${compileParam(scope, itemNode, 0)})`;
                }

                case "doDeleteFromList": {
                    return `env.listDelete(${compileParam(scope, itemNode, 1)}, ${compileParam(scope, itemNode, 0)})`;
                }

                case "reportListItem": {
                    var index = compileParam(scope, itemNode, 0);
                    var list = compileParam(scope, itemNode, 1);

                    return `env.listGet(${list}, ${index})`;
                }

                case "reportNewList": {
                    return `[${concatList(scope, itemNode.children[0], ", ")}]`;
                }

                case "reportMonadic": {
                    return `env.ops[${compileParam(scope, itemNode, 0)}](${compileParam(scope, itemNode, 1)})`;
                }

                default: {
                    // don't compile event nodes
                    // (the caller function should account for those)
                    if (EVENT_OPCODES.includes(opcode)) return "";

                    var map = OPCODE_MAPPINGS[opcode];
                    if (!map) {
                        console.warn("Unknown opcode " + opcode);
                    }

                    var res = map + "(";

                    // add parameters
                    let params = itemNode.children;
                    for (let i = 0; i < params.length; i++) {
                        let paramNode = params[i];
                        res += compileItem(scope, paramNode);

                        if (i < params.length - 1) {
                            res += ", ";
                        }
                    }

                    res += ")";
                    
                    return res;
                }
            }

            break;
        }

        case "custom-block": {
            let opcode = itemNode.attributes.s.nodeValue;
            let args = concatList(scope, itemNode, ", ");
            
            // pass env into function
            // if there are no arguments, don't put a comma
            if (itemNode.firstElementChild) {
                args = "env, " + args;
            } else {
                args = "env";
            }
            return `yield* env.getFunc(\"${opcode}\")(${args})`;
        }
    }
}

const GeneratorFunction = Object.getPrototypeOf(function*(){}).constructor;

function compileDefinition(xml, globalScope) {
    var header = xml.attributes.s.nodeValue;
    
    // a parameter name in the block header is defined as %'{paramName}'
    // captured name is stored in key #1 of the matchAll return value
    //var paramNamesIt = header.matchAll(/%'([^']*)'/gi);
    var paramTypes = [];
    var paramNames = [];
    var safeParamNames = [];
    var opcodeArr = [];
    var scriptNode;
    //console.log(header.matchAll(/%'([^']*)'/gi));

    for (let node of xml.children) {
        switch (node.nodeName) {
            case "inputs": {
                // get param types
                for (let typeNode of node.children) {
                    paramTypes.push(typeNode.attributes.type.nodeValue);
                }

                break;
            }

            case "script": {
                scriptNode = node;
                break;
            }
        }
    }

    // create function scope
    var scope = new Scope(globalScope);

    // add parameter and create opcode
    let typeIndex = 0;

    for (let word of header.split(" ")) {
        // if this is a parameter name
        if (word[0] === "%") {
            let paramName = word.slice(2, -1);
            let paramType = paramTypes[typeIndex++];

            paramNames.push(paramName);
            safeParamNames.push(scope.addVariable(paramName));
            opcodeArr.push(paramType);
        } else {
            opcodeArr.push(word);
        }
    }

    var opcode = opcodeArr.join(" ");

    console.log(scope);
    console.log(opcode);

    var compiled = `${YIELD_CODE}\n` + compileItem(scope, scriptNode);
    console.log(compiled);

    return {
        func: new GeneratorFunction("env", ...safeParamNames, compiled),
        paramTypes: paramTypes,
        opcode: opcode,
    };
}

function compileScript(xmlRoot, scope) {
    return compileItem(scope, xmlRoot);
}

function getScriptEvent(xmlRoot) {
    var firstChild = xmlRoot.firstChild;
    var opcode = firstChild.attributes.s.nodeValue;
    if (!EVENT_OPCODES.includes(opcode)) return null;

    var res = {};

    switch (opcode) {
        case "receiveGo": {
            res.name = "message";
            res.value = "__shout__go__";
            break;
        }

        case "receiveKey": {
            res.name = "keydown",
            res.value = eval(compileItem(null, firstChild.children[0]));
            break;
        }

        case "receiveMessage": {
            res.name = "message";
            res.value = eval(compileItem(null, firstChild.children[0]));
            break;
        }
        
        default: {
            console.warn("Unknown event opcode " + opcode);
            res.name = opcode;
        }
    }

    // get parameters
    res.params = [];

    if (firstChild.children[1]) {
        for (let paramNode of firstChild.children[1].children) {
            res.params.push(eval(compileItem(null, paramNode)));
        }
    }

    return res;
}

export { Scope, compileScript, compileDefinition, getScriptEvent }